/* ***** BEGIN LICENSE BLOCK *****
 *   Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 * 
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is ThumbsDown.
 *
 * The Initial Developer of the Original Code is
 * Atte Kemppilä.
 * Portions created by the Initial Developer are Copyright (C) 2007
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 * 
 * ***** END LICENSE BLOCK ***** */


const EXPORTED_SYMBOLS = ["ScriptRunner"];

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
const Cu = Components.utils;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

Cu.import("resource://thumbsdown/ResourceLoader.jsm");
Cu.import("resource://thumbsdown/ThumbsDown.jsm");
Cu.import("resource://thumbsdown/util.jsm");


const MAX_INT = 4294967295;  // 2^32 - 1

const XPathResult = Ci.nsIDOMXPathResult;

// subject: scriptRunner; data: old scripts count
const TOPIC_NEW     = "thumbsdown-script-new";
// subject: scriptRunner; data: list of removed indexes in *descending* order (as array.toSource())
const TOPIC_DESTROY = "thumbsdown-script-destroy";
// subject: script; data: null
const TOPIC_CHANGE  = "thumbsdown-script-change";

const SCRIPT_ERROR = "thumbsdown_ScriptError";

const STATE_QUEUED     = 0;
const STATE_EVALUATING = 1;
const STATE_LOADING    = 2;
const STATE_CALLBACK   = 3;
const STATE_CANCELLED  = 4;
const STATE_SUCCESS    = 5;
const STATE_ERROR      = 6;

const CUSTOM_CODE_TEMPLATE_URIS =
    ["chrome://thumbsdown/content/templates/getElementById.js",
     "chrome://thumbsdown/content/templates/RegExp.js"]

var Application = Cc["@mozilla.org/fuel/application;1"].getService(Ci.fuelIApplication);


var sandboxFunctions =
{
    LOG: function LOG(message)
    {
        Application.console.log(message);
    },
    
    LOAD: function LOAD(url, allow)
    {
        allow = allow || {};
        var action = {type: "LOAD", value: url, allow: allow};
        var json = JSON.stringify(action);
        throw json;
    },
    
    REDIRECT: function REDIRECT(url)
    {
        var action = {type: "REDIRECT", value: url};
        var json = JSON.stringify(action);
        throw json;
    },
    
    RETURN: function RETURN(value)
    {
        if (! Array.isArray(value))
            value = [value];
        
        var urls = [((typeof(x) == "string") ? {url: x} : x) for each (x in value)];
        var action = {type: "RETURN", value: urls};
        var json = JSON.stringify(action);
        throw json;
    }
}

sandboxFunctions.TD_log = sandboxFunctions.LOG;

function TD_xpath(doc, expr, type)
{
    type = type || XPathResult.ANY_TYPE;
    
    switch (type)
    {
        case "boolean": type = XPathResult.BOOLEAN_TYPE; break;
        case "number": type = XPathResult.NUMBER_TYPE; break;
        case "string": type = XPathResult.STRING_TYPE; break;
        case "node": type = XPathResult.FIRST_ORDERED_NODE_TYPE; break;
    }
    
    var result = doc.evaluate(expr, doc, null, type, null);
    
    switch (result.resultType)
    {
        case XPathResult.BOOLEAN_TYPE: return result.booleanValue;
        case XPathResult.NUMBER_TYPE: return result.numberValue;
        case XPathResult.STRING_TYPE: return result.stringValue;
        case XPathResult.FIRST_ORDERED_NODE_TYPE: return result.singleNodeValue;
        default: return result;
    }
}


function _ScriptRunner()
{
    this.wrappedJSObject = this;
    this.scripts = [];
    this.scriptIndex = null;
    this.count = {running: 0, cancelled: 0, success: 0, error: 0};
    this.customCodeTemplates = [];
    
    Services.obs.addObserver(this, TOPIC_CHANGE, true);
}

_ScriptRunner.prototype =
{
    QueryInterface: XPCOMUtils.generateQI(
        [Ci.nsIObserver,
         Ci.nsISupports,
         Ci.nsISupportsWeakReference]),
    
    // nsIObserver
    observe: function(subject, topic, data)
    {
        if (topic != TOPIC_CHANGE)
            throw new Error("unknown topic: " + topic);
        
        var script = subject.wrappedJSObject;
        
        if ((script.oldState == STATE_QUEUED) && script.isRunning)
            this.count.running++;
        else if (script.isFinished)
        {
            this.count.running--;
            switch (script.state)
            {
                case STATE_CANCELLED: this.count.cancelled++; break;
                case STATE_SUCCESS:   this.count.success++; break;
                case STATE_ERROR:     this.count.error++; break;
            }
            this._startNextScript();
        }
    },
    
    get length() { return this.scripts.length; },
    
    addScripts: function(scriptArgs, forceStart)
    {
        this._initScriptIndex();
        var oldCount = this.scripts.length;
        var newScripts = [];
        for (var i = 0; i < scriptArgs.length; i++)
        {
            var args = scriptArgs[i];
            var script = new Script(args.id, args.name, args.code, args.documentUrl, args.thumbnail, args.callback);
            newScripts.push(script);
            this.scripts.push(script);
            this.scriptIndex[script.id] = oldCount + i;
        }
        if (scriptArgs.length == 0)
            return;
        Services.obs.notifyObservers(this, TOPIC_NEW, oldCount);
        if (forceStart)
            for each (var script in newScripts)
                schedule(script, script.start);
        else
            this._startNextScript();
    },
    
    removeScripts: function(indexes)
    {
        var removed = [];
        indexes = indexes.concat();
        indexes.reverse();
        for each (var index in indexes)
        {
            if (! ((0 <= index) && (index < this.scripts.length) && (! this.scripts[index].isRunning)))
                continue;
            var script = this.scripts.splice(index, 1)[0];
            switch (script.state)
            {
                case STATE_CANCELLED: this.count.cancelled--; break;
                case STATE_SUCCESS:   this.count.success--; break;
                case STATE_ERROR:     this.count.error--; break;
            }
            removed.push(index);
        }
        if (removed.length == 0)
            return;
        this.scriptIndex = null;
        Services.obs.notifyObservers(this, TOPIC_DESTROY, JSON.stringify(removed));
    },
    
    getWrappedScriptCode: function(attrName, attrValue)
    {
        var useId = ((attrName == "id") && /^[-\w]+$/.test(attrValue));
        var templateIndex = (useId ? 0 : 1);
        
        var code = this.customCodeTemplates[templateIndex];
        if (! code)
        {
            var uri = CUSTOM_CODE_TEMPLATE_URIS[templateIndex];
            if (uri)
            {
                code = loadContent(uri);
                this.customCodeTemplates[templateIndex] = code;
            }
        }
        
        if (useId)
            return code.replace(/XXX/, attrValue);
        else
        {
            // pattern needs to be escaped because it's a string in the |code|
            var pattern = attrValue.replace("\\", "\\\\", "g").replace('"', '\"', "g");
            code = code.replace(/"src"/, '"' + attrName + '"');
            code = code.replace(/\^.*\$/, "^" + pattern + "$");
            return code;
        }
    },
    
    _startNextScript: function()
    {
        if (this.count.running > 0)
            return;
        for each (var script in this.scripts)
            if (script.isQueued)
            {
                schedule(script, script.start);
                return;
            }
    },
    
    _getScriptIndex: function(script)
    {
        this._initScriptIndex();
        var index = this.scriptIndex[script.id];
        return index;
    },
    
    _initScriptIndex: function()
    {
        if (this.scriptIndex)
            return;
        this.scriptIndex = {};
        for (var i = 0; i < this.scripts.length; i++)
            this.scriptIndex[this.scripts[i].id] = i;
    }
}


function Script(scriptId, name, code, documentUrl, thumbnail, callback)
{
    this.wrappedJSObject = this;
    this.id = Script.nextId();
    this.scriptId = scriptId;
    this.name = name;
    this.code = code;
    this.documentUrl = documentUrl;
    this.thumbnail = thumbnail;
    this.thumbnailUrl = thumbnail.thumbnailUrl;
    this.linkUrl = thumbnail.linkUrl;
    this.callback = callback;
    this._state = STATE_QUEUED;
    this.oldState = null;
    this.startTime = null;
    this.endTime = null;
    this.scriptState = 0;
    this.resourceLoader = null;
    this.result = null;
    this.error = null;
}

Script._nextId = 1;
Script.nextId = function()
{
    return String((Script._nextId <= 0) ? 1 : Script._nextId++);
};

Script.prototype =
{
    get index() { return gScriptRunner._getScriptIndex(this); },
    
    get state() { return this._state; },
    set state(value)
    {
        if (value != this._state)
        {
            this.oldState = this._state;
            this._state = value;
            Services.obs.notifyObservers(this, TOPIC_CHANGE, null);
        }
    },
    
    get isQueued()    { return (this._state == STATE_QUEUED); },
    get isCancelled() { return (this._state == STATE_CANCELLED); },
    get isSuccess()   { return (this._state == STATE_SUCCESS); },
    get isError()     { return (this._state == STATE_ERROR); },
    get isRunning()   { return ((this._state == STATE_EVALUATING) || (this._state == STATE_LOADING) || (this._state == STATE_CALLBACK)); },
    get isFinished()  { return ((this._state == STATE_CANCELLED)  || (this._state == STATE_SUCCESS) || (this._state == STATE_ERROR)); },
    
    start: function()
    {
        if (! this.isQueued)
            return;
        this.startTime = new Date();
        this.endTime = null;
        this.result = null;
        this.error = null;
        this._eval(0, this.documentUrl);
    },
    
    cancel: function()
    {
        this.thumbnail = null;
        
        if (! this.isRunning)
            return;
        if (this.resourceLoader)
        {
            this.resourceLoader.cancel();
            this.resourceLoader = null;
        }
        
        this.endTime = new Date();
        this.state = STATE_CANCELLED;
    },
    
    _eval: function(scriptState, docUrl, doc)
    {
        try
        {
            if (this.isCancelled)
                return;
            this.scriptState = scriptState;
            this.state = STATE_EVALUATING;
            var sandbox = this._initSandbox(docUrl, doc);
            var result = this._evalInSandbox(this.code, sandbox);
            var action = JSON.parse(result);
            this._processAction(docUrl, action);
        }
        catch (e)
        {
            // script errors are already reported in _evalInSandbox()
            if (e.name != SCRIPT_ERROR)
                Cu.reportError(e);
            this._handleError(e);
        }
    },
    
    _initSandbox: function(docUrl, doc)
    {
        var sandbox = new Cu.Sandbox(docUrl);
        sandbox.XPathResult = XPathResult;
        // thumbsdown properties
        sandbox.state = this.scriptState;
        sandbox.thumbnail = this.thumbnail;
        sandbox.thumbnailUrl = this.thumbnailUrl;
        sandbox.linkUrl = this.linkUrl;
        
        if (doc)
        {
            sandbox.document = doc;
            var msg = "ThumbsDown: script '" + this.name + "': Function $(id) has been deprecated in ThumbsDown scripts. Use document.getElementById(id) instead."
            sandbox.importFunction(function(id) { Application.console.log(msg); return doc.getElementById(id); }, "$");
            sandbox.importFunction(function(expr, type) { return TD_xpath(doc, expr, type); }, "TD_xpath");
        }
        
        for (let [name, func] in Iterator(sandboxFunctions))
            sandbox.importFunction(func, name);
        
        return sandbox;
    },
    
    _evalInSandbox: function(code, sandbox)
    {
        try
        {
            // lineFinder trick is taken from GreaseMonkey
            // workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=307984
            var lineFinder = new Error();
            // TODO: evalInSandbox(code, sandbox, jsVersion, filename, lineNumber)
            Cu.evalInSandbox(code, sandbox);
            throw new Error("abnormal exit");
        }
        catch (e if (typeof(e) == "string"))
        {
            return e;
        }
        catch (e)
        {
            // Line number is reported as max int in edge cases.
            // Sometimes the right one is in the "location", instead. Look there.
            var line = e.lineNumber;
            if (line == MAX_INT)
                if (e.location && e.location.lineNumber)
                    line = e.location.lineNumber;
                else
                    line = 0;
            line = line - lineFinder.lineNumber - 1;
            
            // one cannot modify error and report it with Cu.reportError (it shows original values)
            // therefore we report it manually in error console
            var scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError);
            var msg = SCRIPT_ERROR + ": " + e.name + ": " + e.message;
            var srcName = "ThumbsDown Script: " + this.name;
            var srcLine = null;
            var column = e.columnNumber;
            var flag = Ci.nsIScriptError.errorFlag;
            var category = null;
            scriptError.init(msg, srcName, srcLine, line, column, flag, category);
            Services.console.logMessage(scriptError);
            
            // error still needs to be thrown, just not reported
            e.message = e.name + ": " + e.message + " (line: " + line + ")";
            e.name = SCRIPT_ERROR;
            e.fileName = srcName;
            e.lineNumber = line;
            throw e;
        }
    },
    
    _processAction: function(docUrl, action)
    {
        switch (action.type)
        {
            case "LOAD":
                this._load(action.value, docUrl, action.allow);
                break;
            case "REDIRECT":
                this._redirect(action.value, docUrl);
                break;
            case "RETURN":
                this._doCallback(action.value, docUrl);
                break;
            default:
                throw new Error("invalid script result action: '" + action.type + "'");
        }
    },
    
    _load: function(url, referer, allow)
    {
        var uri = Services.io.newURI(url, null, null);
        var headers = {"Referer": referer};
        var self = this;
        var onSuccess = function() { self._onLoadSuccess(); }
        var onError = function() { self._onLoadError(); }
        var timeout = ThumbsDown.prefs.getIntPref("scriptrunner.requestTimeout");
        this.resourceLoader = new ResourceLoader("GET", uri, headers, onSuccess, onError, timeout);
        this.resourceLoader.allow = allow;
        this.resourceLoader.load();
        this.state = STATE_LOADING;
    },
    
    _onLoadSuccess: function()
    {
        if (this.isCancelled)
            return;
        
        try
        {
            var docUrl = this.resourceLoader.uri.spec;
            var doc = this.resourceLoader.content;
            this._eval(this.scriptState + 1, docUrl, doc);
        }
        finally
        {
            // ff36: clean up must be done *after* eval
            this.resourceLoader.destroy();
            this.resourceLoader = null;
        }
    },
    
    _onLoadError: function()
    {
        if (this.isCancelled)
            return;
        // TODO: error message from resource -> finish(new Error(msg))
        var msg;
        if (this.resourceLoader.isTimeout)
            msg = "request timeout";
        else
            msg = "load error";
        this.resourceLoader.destroy();
        this.resourceLoader = null;
        this._handleError(new Error(msg));
    },
    
    _redirect: function(url, docUrl)
    {
        // FIXME: scriptrunner's redirect support is disabled atm
        throw new Error("REDIRECT() is not supported");
        /*
        var script = ScriptManager.lookup(url, null);
        if (! script)
            throw new Error("failed redirect: " + url);
        
        script.lastUsed = timestamp;
        script.counter++;
        
        switch (script.type)
        {
            case "direct":
                this._doCallback(url, docUrl);
                break;
            case "regexp":
                this._doCallback(url.replace(script.pattern, script.template), url);
                break;
            case "custom":
                var args = {};
                args.id = script.id;
                args.name = script.name;
                args.code = script.code;
                args.documentUrl = docUrl;
                args.linkUrl = url;
                args.callback = this.callback;
                gScriptRunner.addScripts([args], true);
                this.child = gScriptRunner.scripts[gScriptRunner.scripts.length - 1];
                this.child.parent = this;
                schedule(this, this._handleSuccess);
                break;
            case "wrapped":
                var args = {};
                args.id = script.id;
                args.name = script.name;
                args.code = gScriptRunner.getWrappedScriptCode(script.attrName, script.attrValue.pattern);
                args.documentUrl = docUrl;
                args.linkUrl = url;
                args.callback = this.callback;
                gScriptRunner.addScripts([args], true);
                this.child = gScriptRunner.scripts[gScriptRunner.scripts.length - 1];
                this.child.parent = this;
                schedule(this, this._handleSuccess);
                break;
            default:
                throw new Error("redirect: unknown type: " + script.type);
        }
        */
    },
    
    _doCallback: function(results, referer)
    {
        for each (let result in results)
            result.referer = result.referer || referer;
        
        this.result = results;
        this.state = STATE_CALLBACK;
        this.callback(this);
        this._handleSuccess();
    },
    
    _handleSuccess: function()
    {
        this.thumbnail = null;
        this.endTime = new Date();
        this.state = STATE_SUCCESS;
    },
    
    _handleError: function(error)
    {
        this.thumbnail = null;
        this.result = null;
        this.error = error;
        this.endTime = new Date();
        this.state = STATE_ERROR;
        this.callback(this);
    }
}


const ScriptRunner = new _ScriptRunner();
const gScriptRunner = ScriptRunner;
